Skip to content

Conversation

@ejdre-vestas
Copy link

@ejdre-vestas ejdre-vestas commented Oct 30, 2025

I believe that when #1906 was introduced, a bug was also added.

When the transaction is finalized, the code is calling _monitor.ReleaseTransaction, and will eventually do:

if (keepLocked == false)
{
_locker.ExitTransaction();
}

The finalizers always run on a specific thread controlled by the Garbage Collector, so the thread that is releasing the transaction will never have acquired a lock.

I was running version 5.0.15, which is before this band-aid fix #2280 (two of the referenced issues also contain stack traces starting on a Finalize), so I was able to consistently get an exception anytime the Finalize was called.

Even though the exception is hidden on newer versions, it still makes sense to me to have this fixed.
Edit: The new version will still throw an exception in another place, so this PR is still required to fix problems calling finalizers.
2 exceptions still come up without this fix:

Unhandled exception. System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'System.Threading.ThreadLocal`1[[LiteDB.Engine.TransactionService, LiteDB, Version=6.0.0.0, Culture=neutral, PublicKeyToken=null]]'.
   at System.Threading.ThreadLocal`1.GetValueSlow()
   at LiteDB.Engine.TransactionMonitor.ReleaseTransaction(TransactionService transaction) in /home/edureis95/repo/LiteDB/LiteDB/Engine/Services/TransactionMonitor.cs:line 145
   at LiteDB.Engine.TransactionService.Dispose(Boolean dispose) in /home/edureis95/repo/LiteDB/LiteDB/Engine/Services/TransactionService.cs:line 446
   at LiteDB.Engine.TransactionService.Finalize() in /home/edureis95/repo/LiteDB/LiteDB/Engine/Services/TransactionService.cs:line 79
   at System.GC.RunFinalizers()

Unhandled exception. LiteDB.LiteException: current thread must contains transaction parameter
   at LiteDB.Constants.ENSURE(Boolean conditional, String message) in /home/edureis95/repo/LiteDB/LiteDB/Utils/Constants.cs:line 146
   at LiteDB.Engine.TransactionMonitor.ReleaseTransaction(TransactionService transaction) in /home/edureis95/repo/LiteDB/LiteDB/Engine/Services/TransactionMonitor.cs:line 145
   at LiteDB.Engine.TransactionService.Dispose(Boolean dispose) in /home/edureis95/repo/LiteDB/LiteDB/Engine/Services/TransactionService.cs:line 446
   at LiteDB.Engine.TransactionService.Finalize() in /home/edureis95/repo/LiteDB/LiteDB/Engine/Services/TransactionService.cs:line 79
   at System.GC.RunFinalizers()

Copilot AI review requested due to automatic review settings October 30, 2025 14:56
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR refactors the transaction disposal logic by extracting a new RemoveTransaction method and removes unused imports. The primary goal is to provide a way to dispose and remove transactions from the monitor without releasing thread locks, separating concerns between transaction cleanup and lock management.

  • Extracted RemoveTransaction method from ReleaseTransaction to separate transaction disposal from lock release
  • Updated TransactionService.Dispose to call RemoveTransaction instead of ReleaseTransaction when called from finalizer
  • Removed unused imports (System.Collections.Concurrent, System.Runtime.InteropServices, and System.Threading from TransactionService)

Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
LiteDB/Engine/Services/TransactionService.cs Updated finalizer path to call RemoveTransaction instead of ReleaseTransaction; removed unused imports
LiteDB/Engine/Services/TransactionMonitor.cs Extracted RemoveTransaction method from ReleaseTransaction; removed unused imports; minor whitespace fixes

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

public bool RemoveTransaction(TransactionService transaction)
{
// dispose current transaction
transaction.Dispose();
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling transaction.Dispose() within RemoveTransaction creates infinite recursion. The finalizer calls Dispose(false), which calls RemoveTransaction, which calls Dispose() again. This will result in a stack overflow. The RemoveTransaction method should not call transaction.Dispose() since it's already being called from within the transaction's own Dispose(bool) method.

Suggested change
transaction.Dispose();
// transaction.Dispose(); // Removed to prevent infinite recursion

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not change this behavior, but I think this comment is wrong anyway.
Calling the Dispose here will do

this.Dispose(true);
GC.SuppressFinalize(this);

The dispose boolean is true, and the finalizer will never be called because that is being suppressed.


// check if current thread contains more query transactions
keepLocked = _transactions.Values.Any(x => x.ThreadID == Environment.CurrentManagedThreadId);
return keepLocked = _transactions.Values.Any(x => x.ThreadID == Environment.CurrentManagedThreadId);
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assignment to keepLocked is useless, since its value is never read.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's used in the original ReleaseTransaction method.

@JKamsker
Copy link
Collaborator

Thank you for that! Please target your pr at dev and provide atleast one regression test if possible.

@JKamsker
Copy link
Collaborator

@codex review

@chatgpt-codex-connector
Copy link

Codex Review: Didn't find any major issues. Chef's kiss.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ejdre-vestas ejdre-vestas changed the base branch from master to dev October 31, 2025 09:14
@ejdre-vestas ejdre-vestas force-pushed the bugfix/fix-finalize-causing-synchronization-lock-exception branch from 5828175 to 8b773ba Compare October 31, 2025 09:18
@ejdre-vestas ejdre-vestas force-pushed the bugfix/fix-finalize-causing-synchronization-lock-exception branch from 8b773ba to ca7037f Compare October 31, 2025 09:19
@ejdre-vestas
Copy link
Author

Thank you for that! Please target your pr at dev and provide atleast one regression test if possible.

I have added the test, and if you switch the code back to call _monitor.ReleaseTransaction(this);, the test will fail with UnhandledException mentioned in the PR description. I know it's annoying that this is unhandled, but the GC thread "swallows" the exception, so you cannot catch it.

@ejdre-vestas ejdre-vestas changed the title Fix transaction finalizer causing SynchronizationLockException Fix transaction finalizer causing LiteDB.LiteException/System.ObjectDisposedException Oct 31, 2025
@ejdre-vestas
Copy link
Author

ejdre-vestas commented Nov 3, 2025

Even though I made this fix, I think the overall best change is to remove the finalizer completely.

Please someone correct me if I'm wrong but the conclusion that led to this implementation (written in #1772 (comment)), is not valid.

The author wanted to remove a TransactionService from the TransactionMonitor when a thread ends. From his sentence, he believed that the thread ending would trigger the finalizer. However, that's not how GC does things. The finalizer will only ever be called when there are no longer any strong references to an object, and TransactionMonitor holds a strong reference to all TransactionServices. So, essentially, the finalizer will never be called unless the TransactionService is removed from TransactionMonitor (or both are ready to be collected by the GC). If the whole point of the finalizer is to perform this removal, you can see where I'm getting at; it will never do what it was supposed to.

@JKamsker please let me know how I should proceed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants